篇首语:本文由编程笔记#小编为大家整理,主要介绍了Compose 动画边学边做 - 夏日彩虹相关的知识,希望对你有一定的参考价值。
Compose 在动画方面下足了功夫,提供了种类丰富的 API。但也正由于 API 种类繁多,如果想一气儿学下来,可能会消化不良导致似懂非懂。结合例子学习是一个不错的方法,本文就带大家边学边做,通过高仿微博长按点赞的彩虹动画,学习和实践 Compose 动画的相关技巧。
原版:微博长按点赞 | 本文:掘金夏日主题 |
---|---|
代码地址: github.com/vitaviva/An…
Compose 动画 API 在使用场景的维度上大体分为两类:高级别 API 和低级别 API。就像编程语 言分为高级语言和低级语言一样,这列高级低级指 API 的易用性:
AnimatedVisibility
以及 AnimatedContent
等,它们被设计成 Composable 组件,可以在声明式布局中与其他组件融为一体。//Text通过动画淡入
var editable by remember mutableStateOf(true)
AnimatedVisibility(visible = editable)
Text(text = "Edit")
animateFloatAsState
系列了,它们也是 Composable 函数,可以参与 Composition 的组合过程。//动画改变 Box 透明度
val alpha: Float by animateFloatAsState(if (enabled) 1f else 0.5f)
Box(
Modifier.fillMaxSize()
.graphicsLayer(alpha = alpha)
.background(Color.Red)
)
处于上层的 API 由底层 API 支撑实现,TargetBasedAnimation
是开发者可直接使用的最低级 API。Animatable 也是一个相对低级的 API,它是一个动画值的包装器,在协程中完成状态值的变化,向上提供对 animate*AsState
的支撑。它与其他 API 不同,是一个普通类而非一个 Composable 函数,所以可以在 Composable 之外使用,因此更具灵活性。本例子的动画主要也是依靠它完成的。
// Animtable 包装了一个颜色状态值
val color = remember Animatable(Color.Gray)
LaunchedEffect(ok)
// animateTo 是个挂起函数,驱动状态之变化
color.animateTo(if (ok) Color.Green else Color.Gray)
Box(Modifier.fillMaxSize().background(color.value))
无论高级别 API 还是低级别 API ,它们都遵循状态驱动的动画方式,即目标对象通过观察状态变化实现自身的动画。
长按点赞的动画乍看之下非常复杂,但是稍加分解后,不难发现它也是由一些常见的动画形式组合而成,因此我们可以对其拆解后逐个实现:
传统视图动画可以作用在 View 上,通过动画改变其属性;也可以在 onDraw
中通过不断重绘实现逐帧的动画效果。 Compose 也同样,我们可以在 Composable 中观察动画状态,通过重组实现动画效果(本质是改变 UI 组件的布局属性),也可以在 Canvas 中观察动画状态,只在重绘中实现动画(跳过组合)。这个例子的动画效果也需要通过 Canvas 的不断重绘来实现。 Compose 的 Canvas 也可以像 Composable 一样声明式的调用,基本写法如下:
Canvas
...
drawRainbow(rainbowState) //绘制彩虹
...
drawEmoji(emojiState) //绘制表情
...
drawFlow(flowState) //绘制烟花
...
State 的变化会驱动 Canvas 会自动重绘,无需手动调用 invalidate
之类的方法。那么接下来针对彩虹、表情、烟花等各种动画的实现,我们的工作主要有两个:
对于彩虹动画,唯一的动画状态就是圆的半径,其值从 0F 过渡到 screensize,圆形面积铺满至整个屏幕。我们使用 Animatable
包装这个状态值,调用 animateTo
方法可以驱动状态变化:
val raduis = Animatable(0f) //初始值 0f
radius.animateTo(
targetValue = screenSize, //目标值
animationSpec = tween(
durationMillis = duration, //动画时长
easing = FastOutSlowInEasing //动画衰减效果
)
)
animationSpec
用来指定动画规格,不同的动画规格决定了了状态值变化的节奏。Compose 中常用的创建动画规格的方法有以下几种,它们创建不同类型的动画规格,但都是 AnimationSpec
的子类:
要实现上面这样多个彩虹叠加的效果,我们还需有多个 Animtable
同时运行,在 Canvas 中依次对它们进行绘制。绘制彩虹除了依靠 Animtable 的状态值,还有 Color 等其他信息,因此我们定义一个 AnimatedRainbow
类保存包括 Animtable 在内的绘制所需的的状态
class AnimatedRainbow(
//屏幕尺寸(宽边长边大的一方)
private val screenSize: Float,
//RainbowColors是彩虹的候选颜色
private val color: Brush = RainbowColors.random(),
//动画时长
private val duration: Int = 3000
)
private val radius = Animatable(0f)
suspend fun startAnim() = radius.animateTo(
targetValue = screenSize * 1.6f, // 关于 1.6f 后文说明
animationSpec = tween(
durationMillis = duration,
easing = FastOutSlowInEasing
)
)
我们还需要一个集合来管理运行中的 AnimatedRainbow
。这里我们使用 Compose 的 MutableStateList
作为集合容器,MutableStateList
中的元素发生增减时,可以被观察到,而当我们观察到新的 AnimatedRainbow
被添加时,为它启动动画。关键代码如下:
//MutableStateList 保存 AnimatedRainbow
val animatedRainbows = mutableStateListOf
//长按屏幕时,向列表加入 AnimtaedRainbow, 意味着增加一个新的彩虹
animatedRainbows.add(
AnimatedRainbow(
screenHeightPx.coerceAtLeast(screenWidthPx),
RainbowColors.random()
)
)
我们使用 LaunchedEffect
+ snapshotFlow
观察 animatedRainbows 的变化,代码如下:
LaunchedEffect(Unit)
//监听到新添加的 AnimatedRainbow
snapshotFlow animatedRainbows.lastOrNull()
.filterNotNull()
.collect
launch
//启动 AnimatedRainbow 动画
val result = it.startAnim()
//动画结束后,从列表移除,避免泄露
if (result.endReason == AnimationEndReason.Finished)
animatedRainbows.remove(it)
LaunchedEffect
和 snapshotFlow
都是 Compose 处理副作用的 API,由于不是本文重点就不做深入介绍了,这里只需要知道 LaunchedEffect
是一个提供了执行副作用的协程环境,而 snapshotFlow
可以将 animatedRainbows
中的变化转化为 Flow 发射给下游。当通过 Flow 收集到新加入的 AnimtaedRainbow
时,调用 startAnim
启动动画,这里充分发挥了挂起函数的优势,同步等待动画执行完毕,从 animatedRainbows
中移除 AnimtaedRainbow
即可。
值得一提的是,MutableStateList
的主要目的是在组合中观察列表的状态变化,本例子的动画不发生在组合中(只发生在重绘中),完全可以使用普通的集合类型替代,这里使用 MutableStateList
有两个好处:
我们在 Canvas 中遍历 animatedRainbows 所有的 AnimtaedRainbow 完成彩虹的绘制。彩虹的图形主要依靠 DrawScope
的 drawCircle
完成,比较简单。一点需要特别注意,彩虹动画结束时也要以一个圆形图案逐渐退出直至漏出底部内容,要实现这个效果,用到一个小技巧,我们的圆形绘制使用空心圆 (Stroke ) 而非 实心圆( Fill )
基于以上原则,我们为 AnimatedRainbow 添加单个 AnnimatedRainbow 的绘制方法:
fun DrawScope.draw()
drawCircle(
brush = color, //圆环颜色
center = center, //圆心:点赞位置
radius = radius.value,// Animtable 中变化的 radius 值,
style = Stroke((radius.value * 2).coerceAtMost(_screenSize)),
)
如上,StrokeWidth 覆盖 ScreenSize 之后无需继续增长,而 CircleRadius 的最终尺寸除去 ScreenSize 之外还要将 StrokeWidth 考虑进去,因此前面代码中将 Animtable 的 targetValue 设置为 ScreenSize 的 1.6 倍。
表情动画又由三个子动画组成:旋转动画、透明度动画以及抛物线轨迹动画。像 AnimtaedRainbow 一样,我们定义 AnimatedEmoji
管理每个表情动画的状态,AnimatedEmoji 中通过多个 Animatable 分别管理前面提到的几个子动画
class AnimatedEmoji(
private val start: Offset, //表情抛点位置,即长按的屏幕位置
private val screenWidth: Float, //屏幕宽度
private val screenHeight: Float, //屏幕高度
private val duration: Int = 1500 //动画时长
)
//抛出距离(x方向移动终点),在左右一个屏幕之间取随机数
private val throwDistance by lazy
((start.x - screenWidth).toInt()..(start.x + screenWidth).toInt()).random()
//抛出高度(y方向移动终点),在屏幕顶端到抛点之间取随机数
private val throwHeight by lazy
(0..start.y.toInt()).random()
private val x = Animatable(start.x)//x方向移动动画值
private val y = Animatable(start.y)//y方向移动动画值
private val rotate = Animatable(0f)//旋转动画值
private val alpha = Animatable(1f)//透明度动画值
suspend fun CoroutineScope.startAnim()
async
//执行旋转动画
rotate.animateTo(
360f, infiniteRepeatable(
animation = tween(_duration / 2, easing = LinearEasing),
repeatMode = RepeatMode.Restart
)
)
awaitAll(
async
//执行x方向移动动画
x.animateTo(
throwDistance.toFloat(),
animationSpec = tween(durationMillis = duration, easing = LinearEasing)
)
,
async
//执行y方向移动动画(上升)
y.animateTo(
throwHeight.toFloat(),
animationSpec = tween(
duration / 2,
easing = LinearOutSlowInEasing
)
)
//执行y方向移动动画(下降)
y.animateTo(
screenHeight,
animationSpec = tween(
duration / 2,
easing = FastOutLinearInEasing
)
)
,
async
//执行透明度动画,最终状态是半透明
alpha.animateTo(
0.5f,
tween(duration, easing = CubicBezierEasing(1f, 0f, 1f, 0.8f))
)
)
上面代码中,旋转动画的 AnimationSpec 使用 infiniteRepeatable
创建了一个无限循环的动画,RepeatMode.Restart
表示它的从 0F
过渡到 360F
之后,再次重复这个过程。 除了旋转动画之外,其他动画都会在 duration
之后结束,它们分别在 async
中启动并行执行,awaitAll
等待它们全部结束。而由于旋转动画不会结束,因此不能放到 awaitAll 中,否则 startAnim 的调用方将永远无法恢复执行。
透明度动画中的 easing
指定了一个 CubicBezierEasing
。easing 是动画衰减效果,即动画状态以何种速率逼近目标值。Compose 提供了几个默认的 Easing 类型可供使用,分别是:
//默认的 Easing 类型,以加速度起步,减速度收尾
val FastOutSlowInEasing: Easing = CubicBezierEasing(0.4f, 0.0f, 0.2f, 1.0f)
//匀速起步,减速度收尾
val LinearOutSlowInEasing: Easing = CubicBezierEasing(0.0f, 0.0f, 0.2f, 1.0f)
//加速度起步,匀速收尾
val FastOutLinearInEasing: Easing = CubicBezierEasing(0.4f, 0.0f, 1.0f, 1.0f)
//匀速接近目标值
val LinearEasing: Easing = Easing fraction -> fraction
上图横轴是时间,纵轴是逼近目标值的进度,可以看到除了 LinearEasing
之外,其它的的曲线变化都满足 CubicBezierEasing
三阶贝塞尔曲线,如果默认 Easing 不符合你的使用要求,可以使用 CubicBezierEasing
,通过参数,自定义合适的曲线效果。比如例子中曲线如下:
这个曲线前半程状态值进度非常缓慢,临近时间结束才快速逼近最终状态。因为我们希望表情动画全程清晰可见,透明度的衰减尽量后置,默认 easiing 无法提供这种效果,因此我们自定义 CubicBezierEasing
再来看一下抛物线动画的实现。通常我们可以借助抛物线公式,基于一些动画状态变量计算抛物线坐标来实现动画,但这个例子中我们借助 Easing 更加巧妙的实现了抛物线动画。 我们将抛物线动画拆解为 x 轴和 y 轴两个方向两个并行执行的位移动画,x 轴位移通过 LinearEasing 匀速完成,y 轴又拆分成两个过程
上升和下降的 Easing 曲线互相对称,符合抛物线规律
像彩虹动画一样,我们同样使用一个 MutableStateList 集合管理 AnimatedEmoji 对象,并在 LaunchedEffect 中监听新元素的插入,并执行动画。只是表情动画每次会批量增加多个
//MutableStateList 保存 animatedEmojis
val animatedEmojis = mutableStateListOf
//一次增加 EmojiCnt 个表情
animatedEmojis.addAll(buildList
repeat(EmojiCnt)
add(AnimatedEmoji(offset, screenWidthPx, screenHeightPx, res))
)
//监听 animatedEmojis 变化
LaunchedEffect(Unit)
//监听到新加入的 EmojiCnt 个表情
snapshotFlow animatedEmojis.takeLast(EmojiCnt)
.flatMapMerge it.asFlow()
.collect
launch
with(it)
startAnim()//启动表情动画,等待除了旋转动画外的所有动画结束
animatedEmojis.remove(it) //从列表移除
单个 AnimatedEmoji 绘制代码很简单,借助 DrawScope
的 drawImage
绘制表情素材即可
//当前 x,y 位移的位置
val offset get() = Offset(x.value, y.value)
//图片topLeft相对于offset的距离
val d by lazy Offset(img.width / 2f, img.height / 2f)
//绘制表情
fun DrawScope.draw()
rotate(rotate.value, pivot = offset)
drawImage(
image = img, //表情素材
topLeft = offset - dCenter,//当前位置
alpha = alpha.value, //透明度
)
注意旋转动画实际上是借助 DrawScope
的 rotate
方法实现的,在 block 内部调用 drawImage
指定当前的 alpha
和 topLeft
即可。
烟花动画紧跟在表情动画结束时发生,动画不涉及位置变化,主要是几个花瓣不断缩小的过程。花瓣用圆形绘制,动画状态值就是圆形半径,使用 Animatable 包装。
烟花的绘制还要用到颜色等信息,我们定义 AnimatedFlower 保存包括 Animtable 在内的相关状态。
class AnimatedFlower(
private val intial: Float, //花瓣半径初始值,一般是表情的尺寸
private val duration: Int = 2500
)
//花瓣半径
private val radius = Animatable(intial)
suspend fun startAnim()
radius.animateTo(0f, keyframes
durationMillis = duration
intial / 3 at 0 with FastOutLinearInEasing
intial / 5 at (duration * 0.95f).toInt()
)
这里又出现了一种 AnimationSpec,即帧动画 keyframes
,相对于 tween ,keyframes
可以更精确指定时间区间内的动画进度。比如代码中 radius / 3 at 0
表示 0 秒时状态值达到 intial / 3
,相当于以初始值的 1/3
尺寸出现,这是一般的 tween 难以实现的。另外我们希望花瓣可以持久可见,所以使用 keyframe
确保时间进行到 95% 时,radius 的尺寸仍然清晰可见。
由于烟花动画设计是表情动画的延续,所以它紧跟表情动画执行,共享 CoroutienScope ,不需要借助 LaunchedEffect ,所以使用普通列表定义 animatedFlower 即可:
//animatedFlowers 使用普通列表创建
val animatedFlowers = mutableListOf
launch
with(it) //表情动画执行
startAnim()
animatedEmojis.remove(it)
//创建 AnimatedFlower 动画
val anim = AnimatedFlower(
center = it.offset,
//使用 Palette 从表情图片提取烟花颜色
color = Palette.from(it.img.asandroidBitmap()).generate().let
arrayOf(
Color(it.getDominantColor(Color.Transparent.toArgb())),
Color(it.getVibrantColor(Color.Transparent.toArgb()))
)
,
initial = it.img.run width.coerceAtLeast(height) / 2 .toFloat()
)
animatedFlowers.add(anim) //添加进列表
anim.startAnim() //执行烟花动画
animatedFlowers.remove(anim) //移除动画
烟花的内容绘制,需要计算每个花瓣的位置,一共8个花瓣,各自位置计算如下:
//计算 sin45 的值
val sin by lazy sin(Math.PI / 4).toFloat()
val points
get() = run
val d1 = initial - radius.value
val d2 = (initial - radius.value) * sin
arrayOf(
center.copy(y = center.y - d1), //0点方向
center.copy(center.x + d2, center.y - d2),
center.copy(x = center.x + d1),//3点方向
center.copy(center.x + d2, center.y + d2),
center.copy(y = center.y + d1),//6点方向
center.copy(center.x - d2, center.y + d2),
center.copy(x = center.x - d1),//9点方向
center.copy(center.x - d2, center.y - d2),
)
center
是烟花的中心位置,随着花瓣的变小,同时越来越远离中心位置,因此 d1
和 d2
就是偏离 center 的距离,与 radius 大小成反比。 最后在 Canvas 中绘制这些 points 即可:
fun DrawScope.draw()
points.forEachIndexed index, point ->
drawCircle(color = color[index % 2], center = point, radius = radius.value)
6. 合体效果
最后我们定义一个 AnimatedLike
的 Composable ,整合上面代码
@Composable
fun AnimatedLike(modifier: Modifier = Modifier, state: LikeAnimState = rememberLikeAnimState())
LaunchedEffect(Unit)
//监听新增表情
snapshotFlow state.animatedEmojis.takeLast(EmojiCnt)
.flatMapMerge it.asFlow()
.collect
launch
with(it)
startAnim()
state.animatedEmojis.remove(it)
//添加烟花动画
val anim = AnimatedFlower(
center = it.offset,
color = Palette.from(it.img.asAndroidBitmap()).generate().let
arrayOf(
Color(it.getDominantColor(Color.Transparent.toArgb())),
Color(it.getVibrantColor(Color.Transparent.toArgb()))
)
,
initial = it.img.run width.coerceAtLeast(height) / 2 .toFloat()
)
state.animatedFlowers.add(anim)
anim.startAnim()
state.animatedFlowers.remove(anim)
LaunchedEffect(Unit)
//监听新增彩虹
snapshotFlow state.animatedRainbows.lastOrNull()
.filterNotNull()
.collect
launch
val result = it.startAnim()
if (result.endReason == AnimationEndReason.Finished)
state.animatedRainbows.remove(it)
//绘制动画
Canvas(modifier.fillMaxSize())
//绘制彩虹
state.animatedRainbows.forEach animatable ->
with(animatable) draw()
//绘制表情
state.animatedEmojis.forEach animatable ->
with(animatable) draw()
//绘制烟花
state.animatedFlowers.forEach animatable ->
with(animatable) draw()
我们使用 AnimatedLike
布局就可以为页面添加动画效果了,由于 Canvas 本身是基于 modifier.drawBehind
实现的,我们也可以将 AnimatedLike 改为 Modifier 修饰符使用,这里就不赘述了。 最后,复习一下本文例子中的内容:
Animatable
:包装动画状态值,并且在协程中执行动画,同步返回动画结果AnimationSpec
:动画规格,可以配置动画时长、Easing 等,例子中用到了 tween,keyframes,infiniteRepeatable 等多个动画规格Easing
:动画状态值随时间变化的趋势,通常使用默认类型即可, 也可以基于 CubicBezierEasing 定制。一个例子不可能覆盖到 Compose 所有的动画 API,但是我们只要掌握了上述几个关键知识点,再学习其他 API 就是水到渠成的事情了。
本文转自 https://juejin.cn/post/7101836989602725901,如有侵权,请联系删除。